Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin api ninja spike #117

Merged
merged 10 commits into from
Dec 18, 2024
Merged

Admin api ninja spike #117

merged 10 commits into from
Dec 18, 2024

Conversation

jtmst
Copy link
Collaborator

@jtmst jtmst commented Dec 13, 2024

Issue: #99

Ninja API Spike Findings

Overview

  • Created import/export endpoints for NOFOs
  • Created schemas using Ninja's ModelSchema for automatic model validation
  • Implemented mock bearer token authentication
  • removes JSON view endpoints and tests for import/export
  • adjusts admin fields to reflect
  • adds ENV var for api_token

Key Findings

  1. *Model Validation

    • Django model constraints are preserved (e.g., max_length=511 for html_id)
    • Validation errors are properly caught and returned
    • Complex model relationships (NOFO -> Sections -> Subsections) are handled correctly
  2. Error Handling

    • Meaningful error messages with specific details
    • Proper error structure using ErrorSchema
    • Example error response:
    {
      "message": "Validation error",
      "details": {
        "html_id": ["Ensure this value has at most 511 characters (it has 4081)."]
      }
    }
  3. Authentication

    • Bearer token authentication implemented
    • Routes protected behind authentication
    • Currently using test implementation (accepts "secret" prefix)

Benefits

  1. Schema Management

    • ModelSchema reduces code duplication
    • Automatically stays in sync with model changes
    • Clear documentation via OpenAPI/Swagger UI at /api/docs
  2. Maintainability

    • Clear separation of concerns (schemas.py, api.py)
    • Less overhead and simple setup
    • Type hints and validation built-in

Recommendation

  1. Overall Ninja seems pretty rad. I recommend continue and implement

Screenshots:
API Docs:
image

Schema:
image

@jtmst jtmst requested a review from pcraig3 December 13, 2024 18:17
class BearerAuth(HttpBearer):
def authenticate(self, request, token):
# For testing purposes accept any token that starts with "secret"
if token and token.startswith("secret"):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we harden up our security here by also accepting "s3cr3t"

Copy link
Collaborator

@pcraig3 pcraig3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Over this looks great!

I've updated #75 with what I think is our go-to initial schema, but let's chat about the next steps with regards to that.

Comment on lines 9 to 14
class BearerAuth(HttpBearer):
def authenticate(self, request, token):
# For testing purposes accept any token that starts with "secret"
if token and token.startswith("secret"):
return token
return None
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes sense. I think we should generate some value we can stick in an .env var that we both use for now.

Doesn't make sense for use to use Django auth because we are going to replace that soon.

Until we have actual consumers, we just need something unguessable.


# Create NOFO
nofo = Nofo(**data)
nofo.group = "bloom" # TODO: Get from auth token
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be hardcoded for now 👍

Comment on lines 26 to 42
try:
data = payload.dict()
sections = data.pop("sections", [])

# Create NOFO
nofo = Nofo(**data)
nofo.group = "bloom" # TODO: Get from auth token
nofo.full_clean()
nofo.save()

_build_nofo(nofo, sections)
nofo.save()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very clean! Love to see it.

Comment on lines 40 to 52
except ValidationError as e:
return 400, {"message": "Validation error", "details": e.message_dict}
except Exception as e:
return 400, {"message": str(e)}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you been able to trigger these? When I added an html_id that was over 500 chars, it gave me back a 422 error, not one of these.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be rare but not impossible for full_clean to throw an error related to model validation. I'll make it more specific but keep in in there just in case

Comment on lines 46 to 62
@api.get("/nofo/{nofo_id}", response={200: NofoSchema, 404: ErrorSchema})
def export_nofo(request, nofo_id: int):
"""Export a NOFO by ID"""
try:
nofo = Nofo.objects.get(id=nofo_id)
return 200, nofo
except Nofo.DoesNotExist:
return 404, {"message": "NOFO not found"}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some considerations for this:

  • Non-published NOFOs are secret (which I guess is what the bearer token is for)
  • Archived NOFOs are supposed to have been deleted, so we probably should not return them. If this is the case, we should also 404 them, so that people can't guess when a deleted NOFO exists (🤷 although maybe who cares)

So yeah, to sum up, let's return 404 as well for archived NOFOs.

bloom_nofos/nofos/api/api.py Show resolved Hide resolved
bloom_nofos/nofos/api/schemas.py Show resolved Hide resolved
@jtmst jtmst requested a review from pcraig3 December 17, 2024 20:03
Copy link
Collaborator

@pcraig3 pcraig3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello!

This is all looking super good! Thanks so much for converting those tests over, this is such a neat idea that we changed all the code underneath but the tests still (mostly) work.

I've made some other comments in below, but I also noticed that the validation of the NOFO object doesn't match what's in our models.py.

After removing every field I could, it looks like this is our minimum viable NOFO we can import:

{
  "title": "",
  "filename": "",
  "short_name": "",
  "number": "",
  "opdiv": "",
  "agency": "",
  "tagline": "",
  "application_deadline": "",
  "subagency": "",
  "subagency2": "",
  "author": "",
  "subject": "",
  "keywords": "",
  "inline_css": "",
  "sections": [
  ]
}

Some of these are reasonable, like title, number, etc, but inline_css and subagency2 are examples where most NOFOs won't have those, so they don't need to be there (and they aren't required by models.py).

Also, one test we had earlier with the ImportJSON route would look for len(nofo.sections), so it wasn't enough to have a sections key, you would also need to have at least 1 section (and then ditto with subsections).

For this PR, it's already very large, and, to be honest, it does 95% of what we want. So let's say we can work out the sections/subsections stuff when we look at #75, and what fields are required.

For this one, let's just remove some of the rigidity. These are the fields that I would say we should not require, but use them if they exist in the data:

  • subagency2
  • inline_css
  • flename

If that's too complicated, let me know.

Overall, this is looking awesome and I am excited to see this go in!

)


@api.post("/nofo/import", response={201: SuccessSchema, 400: ErrorSchema})
Copy link
Collaborator

@pcraig3 pcraig3 Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I should have mentioned this earlier, but let's follow REST conventions a little closer.

This means:

  • Let's use a plural object noun for this /api/nofos/{id} (add an "s")
  • POST route should use the same path, doesn't need 'import': /api/nofos
  • Return a 201 response 👍 (we are doing this)
  • Return the newly created object rather than a success message
  • Include a Location header with the URL of the newly created resource (Location: /api/nofo/123)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updated. changed to get and create method names to be more conventional as well

bloom_nofos/nofos/api/api.py Show resolved Hide resolved

# Create NOFO
nofo = Nofo(**data)
nofo.group = "bloom"
Copy link
Collaborator

@pcraig3 pcraig3 Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now.

If we ever get to the point where people are importing NOFOs other than us, we will need to change this from being hardcoded.

Comment on lines +67 to +71
class NofoSchema(NofoBaseSchema):
sections: List[SectionSchema]

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this solves the ordering problem! How does this work (and how did you figure it out)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely by accident. Glad it worked though

Comment on lines 21 to 24
class SectionSchema(ModelSchema):
subsections: List[SubsectionSchema]

class Config:
model = Section
model_fields = ["name", "html_id", "order", "has_section_page"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we do the same thing with creating a base schema for sections as well? Would be nice to see section metadata before the list of subsections.

Comment on lines 78 to 80
excluded_fields = ["id", "archived", "status", "group"]
for field in excluded_fields:
import_data.pop(field, None)
Copy link
Collaborator

@pcraig3 pcraig3 Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we don't even need to do this, right? Even if these fields are included, they aren't used.

How about if we do this:

  • keep them for this test
  • assert that the values for these on the new NOFO are different than these values (assertNotEqual)

Comment on lines +34 to +37
# Load fixture data for import tests
fixture_path = os.path.join(
settings.BASE_DIR, "nofos", "fixtures", "json", "cms-u2u-25-001.json"
)
Copy link
Collaborator

@pcraig3 pcraig3 Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!

There are a few other fixtures in there I used for making sure bad data was overwritten (hrsa- and published-), but I think we can include those checks in the import_nofo test (see comment above) and then just remove these other fixtures.

@pcraig3
Copy link
Collaborator

pcraig3 commented Dec 18, 2024

Once this PR goes in, it resolves a bunch of issues:

@jtmst jtmst requested a review from pcraig3 December 18, 2024 15:35
@jtmst jtmst force-pushed the admin-api-ninja-spike branch from 868e155 to c051f3a Compare December 18, 2024 17:01
Copy link
Collaborator

@pcraig3 pcraig3 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super, I am well pleased with where we landed on this.

Amazing work!

@jtmst jtmst merged commit d42711a into main Dec 18, 2024
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants